import type { Config } from 'cosmiconfig';
import { execa, ExecaError } from 'execa';
import { Kysely, PostgresDialect, sql } from 'kysely';
import { deepStrictEqual } from 'node:assert';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { Pool } from 'pg';
import { dedent } from 'ts-dedent';
import packageJson from '../../package.json';
import { Cli } from './cli';
import { ConfigError } from './config-error';

const BINARY_PATH = join(process.cwd(), packageJson.bin['kysely-codegen']);
const OUTPUT_PATH = join(__dirname, 'test', 'output.snapshot.ts');

const OUTPUT = dedent`
  /**
   * This file was generated by kysely-codegen.
   * Please do not edit it manually.
   */

  import { ColumnType } from "kysely";

  export enum Status {
    Confirmed = "CONFIRMED",
    Unconfirmed = "UNCONFIRMED",
  }

  export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
    ? ColumnType<S, I | undefined, U>
    : ColumnType<T, T | undefined, T>;

  export interface Bacchus {
    bacchusId: Generated<number>;
    status: Status | null;
  }

  export interface DB {
    bacchi: Bacchus;
  }

`;

const down = async (db: Kysely<any>) => {
  await db.schema.dropSchema('cli').cascade().execute();
};

const up = async () => {
  const db = new Kysely<any>({
    dialect: new PostgresDialect({
      pool: new Pool({
        connectionString: 'postgres://user:password@localhost:5433/database',
      }),
    }),
  });

  await db.schema.dropSchema('cli').ifExists().cascade().execute();
  await db.schema.createSchema('cli').execute();
  await db.schema
    .withSchema('cli')
    .createType('status')
    .asEnum(['CONFIRMED', 'UNCONFIRMED'])
    .execute();
  await db.schema
    .createTable('cli.bacchi')
    .addColumn('status', sql`cli.status`)
    .addColumn('bacchus_id', 'serial', (col) => col.primaryKey())
    .execute();

  return db;
};

describe(Cli.name, () => {
  beforeAll(async () => {
    await execa`pnpm build`;
  });

  it('should be able to start the CLI', async () => {
    const output = await execa`node ${BINARY_PATH} --help`.then(
      (r) => r.stdout,
    );
    deepStrictEqual(output.includes('--help, -h'), true);
  });

  it('should be able to run the CLI programmatically with a custom config object', async () => {
    const db = await up();

    const output = await new Cli().run({
      argv: ['--camel-case'],
      config: {
        camelCase: false,
        defaultSchemas: ['cli'],
        dialect: 'postgres',
        includePattern: 'cli.*',
        logLevel: 'silent',
        outFile: null,
        runtimeEnums: 'pascal-case',
        singularize: { '/(bacch)(?:us|i)$/i': '$1us' },
        typeOnlyImports: false,
        url: 'postgres://user:password@localhost:5433/database',
      },
    });

    expect(output).toStrictEqual(
      dedent`
        /**
         * This file was generated by kysely-codegen.
         * Please do not edit it manually.
         */

        import { ColumnType } from "kysely";

        export enum Status {
          Confirmed = "CONFIRMED",
          Unconfirmed = "UNCONFIRMED",
        }

        export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
          ? ColumnType<S, I | undefined, U>
          : ColumnType<T, T | undefined, T>;

        export interface Bacchus {
          bacchusId: Generated<number>;
          status: Status | null;
        }

        export interface DB {
          bacchi: Bacchus;
        }

      `,
    );

    await down(db);
  });

  it('should be able to supply a custom serializer to the config', async () => {
    const db = await up();

    const output = await new Cli().run({
      argv: [
        '--config-file',
        './src/cli/test/config-with-custom-serializer.ts',
      ],
    });

    expect(output).toStrictEqual(
      dedent`
        table bacchi {
          status: status
          bacchus_id: int4
        }

        table enum {
          name: text
        }

        table foo_bar {
          false: bool
          true: bool
          overridden: text
          id: int4
          date: date
          user_status: status
          user_status_2: status
          array: text
          nullable_pos_int: int4
          defaulted_nullable_pos_int: int4
          defaulted_required_pos_int: int4
          child_domain: int4
          test_domain_is_bool: bool
          timestamps: timestamptz
          interval1: interval
          interval2: interval
          json: json
          json_typed: json
          numeric1: numeric
          numeric2: numeric
          enum: text
        }

        table partitioned_table {
          id: int4
        }
      `,
    );

    await down(db);
  });

  it('should be able to run the CLI successfully using a config file', async () => {
    const db = await up();
    await fs.writeFile(OUTPUT_PATH, OUTPUT);
    await execa`node ${BINARY_PATH} --config-file ./src/cli/test/config.cjs --out-file ${OUTPUT_PATH} --verify`;
    await down(db);
  });

  it('should support custom imports and type mapping', async () => {
    const db = await up();

    const output = await new Cli().run({
      argv: ['--camel-case'],
      config: {
        customImports: {
          Instant: './custom-types.js#TemporalInstant',
          Temporal: '@js-temporal/polyfill',
        },
        dialect: 'postgres',
        logLevel: 'silent',
        outFile: null,
        typeMapping: {
          date: 'Temporal.PlainDate',
          interval: 'Temporal.Duration',
          timestamptz: 'Instant',
        },
        url: 'postgres://user:password@localhost:5433/database',
      },
    });

    expect(output).toStrictEqual(
      dedent`
        /**
         * This file was generated by kysely-codegen.
         * Please do not edit it manually.
         */

        import type { TemporalInstant as Instant } from "./custom-types.js";
        import type { Temporal } from "@js-temporal/polyfill";
        import type { ColumnType } from "kysely";

        export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[]
          ? U[]
          : ArrayTypeImpl<T>;

        export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
          ? ColumnType<S[], I[], U[]>
          : T[];

        export type CliStatus = "CONFIRMED" | "UNCONFIRMED";

        export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
          ? ColumnType<S, I | undefined, U>
          : ColumnType<T, T | undefined, T>;

        export type Json = JsonValue;

        export type JsonArray = JsonValue[];

        export type JsonObject = {
          [x: string]: JsonValue | undefined;
        };

        export type JsonPrimitive = boolean | number | string | null;

        export type JsonValue = JsonArray | JsonObject | JsonPrimitive;

        export type Numeric = ColumnType<string, number | string, number | string>;

        export type Status = "CONFIRMED" | "UNCONFIRMED";

        export type TestStatus = "ABC_DEF" | "GHI_JKL";

        export interface CliBacchi {
          bacchusId: Generated<number>;
          status: CliStatus | null;
        }

        export interface Enum {
          name: string;
        }

        export interface FooBar {
          array: string[] | null;
          childDomain: number | null;
          date: Temporal.PlainDate | null;
          defaultedNullablePosInt: Generated<number | null>;
          defaultedRequiredPosInt: Generated<number>;
          enum: string;
          /**
           * This is a comment on a column.
           *
           * It's nice, isn't it?
           */
          false: boolean;
          id: Generated<number>;
          interval1: Temporal.Duration | null;
          interval2: Temporal.Duration | null;
          json: Json | null;
          jsonTyped: Json | null;
          nullablePosInt: number | null;
          numeric1: Numeric | null;
          numeric2: Numeric | null;
          overridden: string | null;
          testDomainIsBool: boolean | null;
          timestamps: ArrayType<Instant> | null;
          true: boolean;
          userStatus: Status | null;
          userStatus2: TestStatus | null;
        }

        export interface PartitionedTable {
          id: Generated<number>;
        }

        export interface DB {
          "cli.bacchi": CliBacchi;
          enum: Enum;
          fooBar: FooBar;
          partitionedTable: PartitionedTable;
        }

      `,
    );

    await down(db);
  });

  it('should return an exit code of 1 if the generated types are not up-to-date', async () => {
    const db = await up();
    const incorrectOutput = OUTPUT.replace('"CONFIRMED"', '"INVALID"');
    await fs.writeFile(OUTPUT_PATH, incorrectOutput);
    let error: ExecaError | undefined;

    try {
      await execa`node ${BINARY_PATH} --config-file ./src/cli/test/config.cjs --out-file ${OUTPUT_PATH} --verify`;
    } catch (caughtError) {
      if (caughtError instanceof ExecaError) {
        error = caughtError;
      }
    }

    expect(error?.exitCode).toBe(1);
    expect(error?.stderr).toContain(
      "Generated types are not up-to-date! Use '--log-level=error' option to view the diff.",
    );

    await down(db);
    await fs.writeFile(OUTPUT_PATH, OUTPUT);
  });

  it('should parse options correctly', () => {
    const assert = (args: string[], expectedOptions: Partial<Config>) => {
      const cliOptions = new Cli().parseOptions(args, { silent: true });
      deepStrictEqual(cliOptions, { camelCase: true, ...expectedOptions });
    };

    assert(['--camel-case'], { camelCase: true });
    assert(['--custom-imports={"InstantRange":"./custom-types"}'], {
      customImports: { InstantRange: './custom-types' },
    });
    assert(['--custom-imports={"MyType":"@org/types#OriginalType"}'], {
      customImports: { MyType: '@org/types#OriginalType' },
    });
    assert(['--date-parser=timestamp'], { dateParser: 'timestamp' });
    assert(['--date-parser=string'], { dateParser: 'string' });
    assert(['--default-schema=foo'], { defaultSchemas: ['foo'] });
    assert(['--default-schema=foo', '--default-schema=bar'], {
      defaultSchemas: ['foo', 'bar'],
    });
    assert(['--dialect=mysql'], { dialect: 'mysql' });
    assert(['--domains'], { domains: true });
    assert(['--exclude-pattern=public._*'], { excludePattern: 'public._*' });
    assert(['--help'], {});
    assert(['-h'], {});
    assert(['--include-pattern=public._*'], { includePattern: 'public._*' });
    assert(['--log-level=debug'], { logLevel: 'debug' });
    assert(['--no-domains'], { domains: false });
    assert(['--no-type-only-imports'], { typeOnlyImports: false });
    assert(['--out-file=./db.ts'], { outFile: './db.ts' });
    assert(
      ['--overrides={"columns":{"table.override":"{ foo: \\"bar\\" }"}}'],
      { overrides: { columns: { 'table.override': '{ foo: "bar" }' } } },
    );
    assert(['--print'], { print: true });
    assert(['--singularize'], { singularize: true });
    assert(
      [
        '--type-mapping={"timestamptz":"Temporal.Instant","tstzrange":"InstantRange"}',
      ],
      {
        typeMapping: {
          timestamptz: 'Temporal.Instant',
          tstzrange: 'InstantRange',
        },
      },
    );
    assert(['--type-only-imports'], { typeOnlyImports: true });
    assert(['--type-only-imports=false'], { typeOnlyImports: false });
    assert(['--type-only-imports=true'], { typeOnlyImports: true });
    assert(['--url=postgres://u:p@s/d'], { url: 'postgres://u:p@s/d' });
    assert(['--verify'], { verify: true });
    assert(['--verify=false'], { verify: false });
    assert(['--verify=true'], { verify: true });
  });

  it('should throw an error if a flag is deprecated', () => {
    expect(() => new Cli().parseOptions(['--schema'])).toThrow(
      new RangeError(
        "The flag 'schema' has been deprecated. Use 'default-schema' instead.",
      ),
    );
    expect(() => new Cli().parseOptions(['--singular'])).toThrow(
      new RangeError(
        "The flag 'singular' has been deprecated. Use 'singularize' instead.",
      ),
    );
  });

  it('should throw an error if the config has an invalid schema', () => {
    const assert = (
      config: any,
      message: string,
      path = [Object.keys(config)[0]!],
    ) => {
      expect(() => new Cli().parseOptions([], { config })).toThrow(
        new ConfigError({ message, path }),
      );
    };

    assert(
      { camelCase: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert(
      { customImports: [] },
      'Invalid input: expected record, received array',
    );
    assert(
      { camelCase: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert(
      { dateParser: 'timestamps' },
      'Invalid option: expected one of "string"|"timestamp"',
    );
    assert(
      { defaultSchemas: 'public' },
      'Invalid input: expected array, received string',
    );
    assert(
      { dialect: 'sqlite3' },
      'Invalid option: expected one of "bun-sqlite"|"kysely-bun-sqlite"|"libsql"|"mssql"|"mysql"|"postgres"|"sqlite"|"worker-bun-sqlite"',
    );
    assert(
      { domains: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert({ envFile: null }, 'Invalid input: expected string, received null');
    assert(
      { excludePattern: false },
      'Invalid input: expected string, received boolean',
    );
    assert(
      { includePattern: false },
      'Invalid input: expected string, received boolean',
    );
    assert(
      { logLevel: 0 },
      'Invalid option: expected one of "silent"|"error"|"warn"|"info"|"debug"',
    );
    assert(
      { numericParser: 'numbers' },
      'Invalid option: expected one of "number"|"number-or-string"|"string"',
    );
    assert(
      { outFile: false },
      'Invalid input: expected string, received boolean',
    );
    assert(
      { overrides: { columns: [] } },
      'Invalid input: expected record, received array',
      ['overrides', 'columns'],
    );
    assert(
      { partitions: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert(
      { print: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert({ runtimeEnums: 'true' }, 'Invalid input');
    assert({ singularize: 'true' }, 'Invalid input');
    assert(
      { typeOnlyImports: 'true' },
      'Invalid input: expected boolean, received string',
    );
    assert({ url: null }, 'Invalid input: expected string, received null');
    assert(
      { verify: 'true' },
      'Invalid input: expected boolean, received string',
    );
  });
});
